/*
 * @(#)TextComponentSearchable.java 10/11/2005
 *
 * Copyright 2002 - 2005 JIDE Software Inc. All rights reserved.
 */
package com.element.swing.search;

import com.element.swing.DelegateAction;
import com.element.util.ListenerUtil;

import javax.swing.*;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.util.HashMap;
import java.util.Iterator;

/**
 * <code>TextComponentSearchable</code> is an concrete implementation of {@link Searchable} that enables the search
 * function in JTextComponent. <p>It's very simple to use it. Assuming you have a JTextComponent, all you need to do is
 * to call
 * <code><pre>
 * JTextComponent textComponent = ....;
 * TextComponentSearchable searchable = new TextComponentSearchable(textComponent);
 * </pre></code>
 * Now the JTextComponent will have the search function.
 * <p/>
 * There is very little customization you need to do to ListSearchable. The only thing you might need is when the
 * element in the JTextComponent needs a special conversion to convert to string. If so, you can override
 * convertElementToString() to provide you own algorithm to do the conversion.
 * <code><pre>
 * JTextComponent textComponent = ....;
 * TextComponentSearchable searchable = new ListSearchable(textComponent) {
 *      protected String convertElementToString(Object object) {
 *          ...
 *      }
 * <p/>
 *      protected boolean isActivateKey(KeyEvent e) { // change to a different activation key
 *          return (e.getID() == KeyEvent.KEY_PRESSED && e.getKeyCode() == KeyEvent.VK_F &&
 * (KeyEvent.CTRL_MASK & e.getModifiers()) != 0);
 *      }
 * };
 * </pre></code>
 * <p/>
 * Additional customization can be done on the base Searchable class such as background and foreground color,
 * keystrokes, case sensitivity. TextComponentSearchable also has a special attribute called highlightColor. You can
 * change it using {@link #setHighlightColor(Color)}.
 * <p/>
 * Due to the special case of JTextComponent, the searching doesn't support wild card '*' or '?' as in other
 * Searchables. The other difference is JTextComponent will keep the highlights after search popup hides. If you want to
 * hide the highlights, just press ESC again (the first ESC will hide popup; the second ESC will hide all highlights if
 * any).
 */
public class TextComponentSearchable extends Searchable implements DocumentListener, PropertyChangeListener {
	private Highlighter.HighlightPainter _highlightPainter;
	private static final Color DEFAULT_HIGHLIGHT_COLOR = new Color(204, 204, 255);
	private Color _highlightColor = null;
	private int _selectedIndex = -1;
	private HighlighCache _highlighCache;

	public TextComponentSearchable(JTextComponent textComponent) {
		super(textComponent);
		_highlighCache = new HighlighCache();
		installHighlightsRemover();
		setHighlightColor(DEFAULT_HIGHLIGHT_COLOR);
	}

	/**
	 * Uninstalls the handler for ESC key to remove all highlights
	 */
	public void uninstallHighlightsRemover() {
		DelegateAction.restoreAction(_component, JComponent.WHEN_FOCUSED, KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0));
	}

	/**
	 * Installs the handler for ESC key to remove all highlights
	 */
	public void installHighlightsRemover() {
		DelegateAction.replaceAction(_component, JComponent.WHEN_FOCUSED, KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), new DelegateAction() {
			@Override
			public boolean isDelegateEnabled() {
				if (_component instanceof JTextComponent) {
					Iterator highlights = _highlighCache.getAllHighlights();
					if (!highlights.hasNext()) {
						return false;
					}
				}
				return super.isDelegateEnabled();
			}

			@Override
			public boolean delegateActionPerformed(ActionEvent e) {
				if (_component instanceof JTextComponent) {
					Iterator highlights = _highlighCache.getAllHighlights();
					if (!highlights.hasNext()) {
						return false;
					}
				}
				removeAllHighlights();
				return true;
			}
		});
	}

	@Override
	public void installListeners() {
		super.installListeners();
		if (_component instanceof JTextComponent) {
			((JTextComponent) _component).getDocument().addDocumentListener(this);
			_component.addPropertyChangeListener("document", this);
		}
	}

	@Override
	public void uninstallListeners() {
		super.uninstallListeners();
		if (_component instanceof JTextComponent) {
			((JTextComponent) _component).getDocument().removeDocumentListener(this);
			_component.removePropertyChangeListener("document", this);
		}
	}

	@Override
	public void setSelectedIndex(int index, boolean incremental) {
		if (_component instanceof JTextComponent) {
			if (index == -1) {
				removeAllHighlights();
				_selectedIndex = -1;
				return;
			}

			if (!incremental) {
				removeAllHighlights();
			}

			String text = getSearchingText();
			try {
				addHighlight(index, text, incremental);
			} catch (BadLocationException e) {
				e.printStackTrace();
			}
		}
	}

	/**
	 * Adds highlight to text component at specified index and text.
	 *
	 * @param index       the index of the text to be highlighted
	 * @param text        the text to be highlighted
	 * @param incremental if this is an incremental adding highlight
	 * @throws BadLocationException
	 */
	protected void addHighlight(final int index, final String text, boolean incremental) throws BadLocationException {
		if (_component instanceof final JTextComponent textComponent) {
			Object obj = textComponent.getHighlighter().addHighlight(index, index + text.length(), _highlightPainter);
			_highlighCache.addHighlight(obj);
			_selectedIndex = index;
			if (!incremental) {
				Runnable runnable = () -> scrollTextVisible(textComponent, index, text.length());
				SwingUtilities.invokeLater(runnable);
			}
		}
	}

	private void scrollTextVisible(JTextComponent textComponent, int index, int length) {
		// scroll highlight visible
		if (index != -1) {
			// Scroll the component if needed so that the composed text
			// becomes visible.
			try {
				Rectangle begin = textComponent.modelToView(index);
				if (begin == null) {
					return;
				}
				Rectangle end = textComponent.modelToView(index + length);
				if (end == null) {
					return;
				}
				Rectangle bounds = _component.getVisibleRect();
				if (begin.x <= bounds.width) { // make sure if scroll back to the beginning as long as selected rect is visible
					begin.width = end.x;
					begin.x = 0;
				} else {
					begin.width = end.x - begin.x;
				}
				textComponent.scrollRectToVisible(begin);
			} catch (BadLocationException ignored) {
			}
		}
	}

	/**
	 * Removes all highlights from the text component.
	 */
	protected void removeAllHighlights() {
		if (_component instanceof JTextComponent) {
			Iterator itor = _highlighCache.getAllHighlights();
			while (itor.hasNext()) {
				Object o = itor.next();
				((JTextComponent) _component).getHighlighter().removeHighlight(o);
			}
			_highlighCache.removeAllHighlights();
		}
	}

	@Override
	public int getSelectedIndex() {
		if (_component instanceof JTextComponent) {
			return _selectedIndex;
		}
		return 0;
	}

	@Override
	public Object getElementAt(int index) {
		String text = getSearchingText();
		if (text != null) {
			if (_component instanceof JTextComponent) {
				int endIndex = index + text.length();
				int elementCount = getElementCount();
				if (endIndex > elementCount) {
					endIndex = getElementCount();
				}
				try {
					return ((JTextComponent) _component).getDocument().getText(index, endIndex - index + 1);
				} catch (BadLocationException e) {
					return null;
				}
			}
		}
		return "";
	}

	@Override
	public int getElementCount() {
		if (_component instanceof JTextComponent) {
			return ((JTextComponent) _component).getDocument().getLength();
		}
		return 0;
	}

	/**
	 * Converts the element in JTextComponent to string. The returned value will be the <code>toString()</code> of
	 * whatever element that returned from <code>list.getModel().getElementAt(i)</code>.
	 *
	 * @param object
	 * @return the string representing the element in the JTextComponent.
	 */
	@Override
	public String convertElementToString(Object object) {
		if (object != null) {
			return object.toString();
		} else {
			return "";
		}
	}

	public void propertyChange(PropertyChangeEvent evt) {
		if (isProcessModelChangeEvent()) {
			hidePopup();
			_text = null;
			if (evt.getOldValue() instanceof Document) {
				((Document) evt.getNewValue()).removeDocumentListener(this);
			}
			if (evt.getNewValue() instanceof Document) {
				((Document) evt.getNewValue()).addDocumentListener(this);
			}
			fireSearchableEvent(new SearchableEvent(this, SearchableEvent.SEARCHABLE_MODEL_CHANGE));
		}
	}

	public void insertUpdate(DocumentEvent e) {
		if (isProcessModelChangeEvent()) {
			hidePopup();
			_text = null;
			fireSearchableEvent(new SearchableEvent(this, SearchableEvent.SEARCHABLE_MODEL_CHANGE));
		}
	}

	public void removeUpdate(DocumentEvent e) {
		if (isProcessModelChangeEvent()) {
			hidePopup();
			_text = null;
			fireSearchableEvent(new SearchableEvent(this, SearchableEvent.SEARCHABLE_MODEL_CHANGE));
		}
	}

	public void changedUpdate(DocumentEvent e) {
		if (isProcessModelChangeEvent()) {
			hidePopup();
			_text = null;
			fireSearchableEvent(new SearchableEvent(this, SearchableEvent.SEARCHABLE_MODEL_CHANGE));
		}
	}

	@Override
	protected boolean isActivateKey(KeyEvent e) {
		if (_component instanceof JTextComponent && ((JTextComponent) _component).isEditable()) {
			return (e.getID() == KeyEvent.KEY_PRESSED && e.getKeyCode() == KeyEvent.VK_F && ListenerUtil.isMenuShortcutKeyDown(e));
		} else {
			return super.isActivateKey(e);
		}
	}

	/**
	 * Gets the highlight color.
	 *
	 * @return the highlight color.
	 */
	public Color getHighlightColor() {
		if (_highlightColor != null) {
			return _highlightColor;
		} else {
			return DEFAULT_HIGHLIGHT_COLOR;
		}
	}

	/**
	 * Changes the highlight color.
	 *
	 * @param highlightColor
	 */
	public void setHighlightColor(Color highlightColor) {
		_highlightColor = highlightColor;
		_highlightPainter = new DefaultHighlighter.DefaultHighlightPainter(_highlightColor);
	}

	@Override
	public int findLast(String s) {
		if (_component instanceof JTextComponent) {
			String text = getDocumentText();
			if (isCaseSensitive()) {
				return text.lastIndexOf(s);
			} else {
				return lastIndexOf(text, s, text.length());
			}
		} else {
			return super.findLast(s);
		}
	}

	private String _text = null;

	/**
	 * Gets the text from Document.
	 *
	 * @return the text of this JTextComponent. It used Document to get the text.
	 */
	private String getDocumentText() {
		if (_text == null) {
			Document document = ((JTextComponent) _component).getDocument();
			try {
				_text = document.getText(0, document.getLength());
			} catch (BadLocationException e) {
				return "";
			}
		}
		return _text;
	}

	@Override
	public int findFirst(String s) {
		if (_component instanceof JTextComponent) {
			String text = getDocumentText();
			if (isCaseSensitive()) {
				return text.indexOf(s);
			} else {
				return indexOf(text, s, 0);
			}
		} else {
			return super.findFirst(s);
		}
	}

	static int lastIndexOf(String source, String target, int fromIndex) {
		int sourceCount = source.length();
		int targetCount = target.length();
		int rightIndex = sourceCount - targetCount;
		if (fromIndex < 0) {
			return -1;
		}
		if (fromIndex > rightIndex) {
			fromIndex = rightIndex;
		}
		/* Empty string always matches. */
		if (targetCount == 0) {
			return fromIndex;
		}
		char[] lowerTarget = target.toLowerCase().toCharArray();
		char[] upperTarget = target.toUpperCase().toCharArray();

		int strLastIndex = targetCount - 1;
		int min = targetCount - 1;
		int i = min + fromIndex;

		while (i >= min) {
			while (i >= min && source.charAt(i) != lowerTarget[strLastIndex] && source.charAt(i) != upperTarget[strLastIndex]) {
				i--;
			}
			if (i < min) {
				break;
			}
			int j = i - 1;
			int start = j - (targetCount - 1);
			int k = strLastIndex - 1;

			while (j > start) {
				char ch = source.charAt(j);
				if (ch != lowerTarget[k] && ch != upperTarget[k]) {
					i--;
					break;
				}
				j--;
				k--;
			}
			if (j <= start) {
				return start + 1;
			}
		}
		return -1;
	}

	private static int indexOf(String source, String target, int fromIndex) {
		int sourceCount = source.length();
		int targetCount = target.length();
		if (fromIndex >= sourceCount) {
			return (targetCount == 0 ? sourceCount : -1);
		}
		if (fromIndex < 0) {
			fromIndex = 0;
		}
		if (targetCount == 0) {
			return fromIndex;
		}
		char[] lowerTarget = target.toLowerCase().toCharArray();
		char[] upperTarget = target.toUpperCase().toCharArray();

		int max = sourceCount - targetCount;

		for (int i = fromIndex; i <= max; i++) {
			/* Look for first character. */
			char c = source.charAt(i);
			if (c != lowerTarget[0] && c != upperTarget[0]) {
				i++;
				while (i <= max && source.charAt(i) != lowerTarget[0] && source.charAt(i) != upperTarget[0]) {
					i++;
				}
			}

			/* Found first character, now look at the rest of v2 */
			if (i <= max) {
				int j = i + 1;
				int end = j + targetCount - 1;
				for (int k = 1; j < end; j++) {
					char ch = source.charAt(j);
					if (ch != lowerTarget[k] && ch != upperTarget[k]) {
						break;
					}
					k++;
				}

				if (j == end) {
					/* Found whole string. */
					return i;
				}
			}
		}
		return -1;
	}

	@Override
	public int findFromCursor(String s) {
		if (isCountMatch()) {
			String text = getDocumentText();
			int oldIndex;
			int newIndex = 0;
			_matchCount = -1;
			do {
				oldIndex = newIndex;
				newIndex = (isCaseSensitive() ? text.indexOf(s, oldIndex) : indexOf(text, s, oldIndex)) + 1;
				_matchCount++;
			}
			while (newIndex > oldIndex);
		}

		if (isReverseOrder()) {
			return reverseFindFromCursor(s);
		}

		if (_component instanceof JTextComponent) {
			String text = getDocumentText();
			int selectedIndex = (getCursor() != -1 ? getCursor() : getSelectedIndex());
			if (selectedIndex < 0)
				selectedIndex = 0;
			int count = getElementCount();
			if (count == 0)
				return s.length() > 0 ? -1 : 0;

			// find from cursor
			int found = isCaseSensitive() ? text.indexOf(s, selectedIndex) : indexOf(text, s, selectedIndex);

			// if not found, start over from the beginning
			if (found == -1) {
				found = isCaseSensitive() ? text.indexOf(s) : indexOf(text, s, 0);
				if (found >= selectedIndex) {
					found = -1;
				}
			}

			return found;
		} else {
			return super.findFromCursor(s);
		}
	}

	@Override
	public int reverseFindFromCursor(String s) {
		if (!isReverseOrder()) {
			return findFromCursor(s);
		}

		if (_component instanceof JTextComponent) {
			String text = getDocumentText();
			int selectedIndex = (getCursor() != -1 ? getCursor() : getSelectedIndex());
			if (selectedIndex < 0)
				selectedIndex = 0;
			int count = getElementCount();
			if (count == 0)
				return s.length() > 0 ? -1 : 0;

			// find from cursor
			int found = isCaseSensitive() ? text.lastIndexOf(s, selectedIndex) : lastIndexOf(text, s, selectedIndex);

			// if not found, start over from the end
			if (found == -1) {
				found = isCaseSensitive() ? text.lastIndexOf(s) : lastIndexOf(text, s, text.length() - 1);
				if (found <= selectedIndex) {
					found = -1;
				}
			}

			return found;
		} else {
			return super.findFromCursor(s);
		}
	}

	@Override
	public int findNext(String s) {
		if (_component instanceof JTextComponent) {
			String text = getDocumentText();
			int selectedIndex = (getCursor() != -1 ? getCursor() : getSelectedIndex());
			if (selectedIndex < 0)
				selectedIndex = 0;
			int count = getElementCount();
			if (count == 0)
				return s.length() > 0 ? -1 : 0;

			// find from cursor
			int found = isCaseSensitive() ? text.indexOf(s, selectedIndex + 1) : indexOf(text, s, selectedIndex + 1);

			// if not found, start over from the beginning
			if (found == -1 && isRepeats()) {
				found = isCaseSensitive() ? text.indexOf(s) : indexOf(text, s, 0);
				if (found > selectedIndex) {
					found = -1;
				}
			}

			return found;
		} else {
			return super.findNext(s);
		}
	}

	@Override
	public int findPrevious(String s) {
		if (_component instanceof JTextComponent) {
			String text = getDocumentText();
			int selectedIndex = (getCursor() != -1 ? getCursor() : getSelectedIndex());
			if (selectedIndex < 0)
				selectedIndex = 0;
			int count = getElementCount();
			if (count == 0)
				return s.length() > 0 ? -1 : 0;

			// find from cursor
			int found = isCaseSensitive() ? text.lastIndexOf(s, selectedIndex - 1) : lastIndexOf(text, s, selectedIndex - 1);

			// if not found, start over from the beginning
			if (found == -1 && isRepeats()) {
				found = isCaseSensitive() ? text.lastIndexOf(s, count - 1) : lastIndexOf(text, s, count - 1);
				if (found < selectedIndex) {
					found = -1;
				}
			}

			return found;
		} else {
			return super.findPrevious(s);
		}
	}

	private static class HighlighCache extends HashMap {
		public void addHighlight(Object obj) {
			put(obj, null);
		}

		public void removeHighlight(Object obj) {
			remove(obj);
		}

		public Iterator getAllHighlights() {
			return keySet().iterator();
		}

		public void removeAllHighlights() {
			clear();
		}
	}

	@Override
	public void hidePopup() {
		super.hidePopup();
		_selectedIndex = -1;
	}

	@Override
	protected void searchingTextEmpty() {
		setSelectedIndex(-1, false);
	}
}
